27 — Shader 着色器

Three.js Journey · Bruno Simon · 难度:简单

27 — Shaders 课程海报

介绍

这一课可能是最受期待的一课。我们已经讨论过 shader,而那是最具挑战性的话题。

有趣的是,我们从一开始就一直在使用它们。当我们创建 Three.js 内置 material 时,那些 material 就是由 shader 组成的。WebGL render 中显示的一切之所以成为可能,都是因为 shader,但现在是时候我们自己来创建它们了。

我们将从解释什么是 shader 以及何时使用它们开始。然后我们会创建自己非常简单的 shader。当然,我们还会介绍 shader 语言的语法。最后,我们会做一些练习来实践经典场景。

什么是 shader

shader 实际上是 WebGL 的主要组成部分之一。如果我们不使用 Three.js 而直接上手 WebGL,它将是我们必须首先学习的内容之一,这正是原生 WebGL 如此困难的原因。

shader 是用 GLSL 编写并发送到 GPU 的程序。它们用于定位 geometry 的每个顶点并为 geometry 的每个可见像素着色。“pixel”这个词并不准确,因为 render 中的每个点不一定对应屏幕上的每个像素,这就是为什么我们更倾向于使用“fragment”这个词,所以如果你看到这两个词,请不要感到惊讶。

然后我们把大量数据发送给 shader,比如顶点坐标、mesh 变换、关于 camera 及其视野的信息、诸如颜色、texture、光、雾等参数。接着 GPU 按照 shader 指令处理所有这些数据,我们的 geometry 就出现在 render 中了。

shader 有两种类型,而且两者我们都需要。

Vertex shader

vertex shader 的目的是定位 geometry 的顶点。思路是发送顶点位置、mesh 变换(如它的位置、旋转和缩放)、camera 信息(如它的位置、旋转和视野)。然后,GPU 会按照 vertex shader 中的指令处理所有这些信息,以便把顶点投影到一个 2D 空间上,这个空间将成为我们的 render——换句话说,我们的 canvas。

使用 vertex shader 时,它的代码会应用到 geometry 的每个顶点上。但有些数据,比如顶点位置,会在每个顶点之间变化。这种类型的数据——即顶点之间会变化的数据——叫做 attribute。但有些数据不需要在每个顶点之间切换,比如 mesh 的位置。是的,mesh 的位置会影响所有顶点,但影响的方式相同。这种类型的数据——即顶点之间不会变化的数据——叫做 uniform。我们稍后会回到 attribute 和 uniform。

vertex shader 先执行。顶点放置好之后,GPU 就知道 geometry 的哪些像素是可见的,然后就可以继续执行 fragment shader。

Fragment shader

fragment shader 的目的是为 geometry 的每个可见 fragment 着色。

同一个 fragment shader 会用于 geometry 的每个可见 fragment。我们可以通过 uniform 向它发送数据(比如颜色)——就像 vertex shader 一样,我们也可以从 vertex shader 向 fragment shader 发送数据。我们把这种类型的数据——即从 vertex shader 传到 fragment shader 的数据——叫做 varying。我们稍后会回到这个话题。

fragment shader 中最直接的指令可以是用同一种颜色为所有 fragment 着色。如果我们只设置了 color 属性,得到的效果就相当于 MeshBasicMaterial。

或者我们可以向 shader 发送更多数据,例如一个光源位置。然后我们可以根据表面正对光源的程度为 fragment 着色。如果场景中有一盏灯,我们就会得到相当于 MeshPhongMaterial 的效果。

总结

vertex shader 在 render 上定位顶点。

fragment shader 为 geometry 的每个可见 fragment(或像素)着色。

fragment shader 在 vertex shader 之后执行。

在每个顶点之间变化的数据(如它们的位置)叫做 attribute,并且只能在 vertex shader 中使用。

在顶点之间不变的数据(如 mesh 位置或颜色)叫做 uniform,并且可以在 vertex shader 和 fragment shader 中使用。

我们可以使用 varying 从 vertex shader 向 fragment shader 发送数据。

为什么要编写我们自己的 shader

Three.js 的 material 试图覆盖尽可能多的情况,但它们有局限。如果我们想打破这些限制,就必须编写自己的 shader。

也可能是出于性能原因。像 MeshStandardMaterial 这样的 material 非常复杂,涉及大量代码和计算。如果我们编写自己的 shader,就可以把功能和计算保持在最低限度。我们对性能有更多的控制权。

编写自己的 shader 也是为 render 添加 post-processing 的绝佳方式,但我们会在一节专门的课程中看到这一点。

一旦你掌握了 shader,它们将成为你所有项目中必不可少的东西。

使用 RawShaderMaterial 创建我们的第一个 shader

要创建我们的第一个 shader,我们需要创建一个特殊的 material。这个 material 可以是 ShaderMaterial 或 RawShaderMaterial。两者的区别在于:ShaderMaterial 会自动向 shader 代码中添加一些代码,而 RawShaderMaterial 顾名思义,什么都不会添加。

我们将从 RawShaderMaterial 开始,以便更好地理解发生了什么。

起始文件包含一个带有 MeshBasicMaterial 的简单平面。

把那个 MeshBasicMaterial 替换为 RawShaderMaterial:

const material = new THREE.RawShaderMaterial()

你应该会得到一个错误。

正如我们之前所说,我们需要同时提供 vertex shader 和 fragment shader。你可以通过 vertexShader 和 fragmentShader 属性来实现:

const material = new THREE.RawShaderMaterial({
    vertexShader: '',
    fragmentShader: ''
})

这种技术的问题在于,单引号内部只能包含一行——双引号也一样。我们的 shader——即使一开始很简单,也会太长而无法写在一行里。

一个可靠的解决方案是使用反引号(back quote)——也叫 backtick、acute 或左引号。大多数现代浏览器都支持它们。这种技术叫做 template literals,我们可以在其中使用换行。

输入这个字符的按键或快捷键取决于你的键盘。这里有一个关于该主题的帖子可以帮到你:https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/879277

找到那个键后,把单引号换成反引号:

const material = new THREE.RawShaderMaterial({
    vertexShader: ``,
    fragmentShader: ``
})

我们终于可以编写 shader 了。只需复制代码即可,稍后我们会解释一切:

const material = new THREE.RawShaderMaterial({
    vertexShader: `
        uniform mat4 projectionMatrix;
        uniform mat4 viewMatrix;
        uniform mat4 modelMatrix;

        attribute vec3 position;

        void main()
        {
            gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        precision mediump float;

        void main()
        {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    `
})

你应该会得到一个红色的平面。恭喜,你可能还不理解这里写的内容,但这是你的第一个 shader,也是一段不可思议旅程的良好开端。

把 shader 分离到不同的文件中

在我们深入研究代码本身之前,让我们先尝试改进工作方式。反引号对于小段代码来说是极好的方案,在以后涉及 shader 的课程中我们也会用到它,但我们缺少语法高亮。一旦我们有多个包含大量代码的 shader,我们的脚本将变得难以忍受。拥有一个良好舒适的开发环境是至关重要的。

Shader 文件

我们将把代码移到单独的文件中。首先,把 vertex shader 代码和 fragment shader 代码分别移到 /src/shaders/test/vertex.glsl 和 /src/shaders/test/fragment.glsl 中。

即使我们的项目中只有一个 shader,尽可能分离并组织好代码也是一个健康的习惯。大型项目可能有几十个自定义 shader。

除非你的代码编辑器已经支持 glsl,否则这两个新文件很可能没有语法高亮。要添加语法高亮,如果你使用 VSCode,请转到插件,搜索 shader,然后安装 Shader languages support for VS Code 插件。如果你使用其他代码编辑器,请寻找兼容的插件,并留意其受欢迎程度和评价。

安装完成后,.glsl 文件上应该会有漂亮的语法高亮。如果没有,请尝试重启你的代码编辑器。

语法高亮很棒,但拥有一个 linter 就更好了。linter 会在你编写代码时验证代码并找出潜在错误。它非常有用,可以让你无需在浏览器中测试结果就能避免基本错误。

在接下来的课程中我们不会使用它,因为安装起来可能很困难,但如果你想尝试一下,如果你使用 VSCode,我推荐使用 GLSL Lint。

对本课来说有点太难了,尤其是因为安装过程取决于你的操作系统和安全设置,还因为这个扩展会在不完整的 shader 上触发错误,这是个问题,因为我们稍后在本课中会编写不完整的 shader。由你决定,但你可以尝试一下。

导入

让我们尝试把文件导入到脚本中:

import testVertexShader from './shaders/test/vertex.glsl'
import testFragmentShader from './shaders/test/fragment.glsl'

遗憾的是,我们会收到一个错误,因为我们的项目不知道如何处理 .glsl 文件。

好消息是,已经有一些解决方案可以在 Vite 中为 shader 添加支持:

但既然我们有选择,应该选哪一个呢?

目前,我们只是希望能够导入 .glsl 文件,而两者都能完美做到。区别在于更特定的功能,其中之一是能够把一个 shader 文件包含到其他 shader 文件中。

这在 3 种情况下很方便:

vite-plugin-glsl 和 vite-plugin-glslify 都能做到,但语法不同。如今 GLSLIFY 算是标准方式,但我发现 vite-plugin-glsl 更易于使用,这就是为什么我们选择它而不是 vite-plugin-glslify。另外请注意,该插件维护良好。

不过,如果某些时候你需要使用 vite-plugin-glslify,过程完全相同,你应该能够自己完成。

要安装 vite-plugin-glsl,请先在终端中关闭服务器,然后运行 npm install vite-plugin-glsl。

然后打开 vite.config.js 文件,在顶部从 vite-plugin-glsl 导入 glsl:

import glsl from 'vite-plugin-glsl'

// ...

在 plugins 数组中,添加对 glsl 函数的调用(不要忘记 restart(…) 后面的 ;:

import glsl from 'vite-plugin-glsl'

// ...

export default {
    // ...
    plugins:
    [
        restart({ restart: [ '../static/**', ] }), // Restart server on static file change
        glsl() // Handle shader files
    ]
}

这就是向 Vite 添加插件所需的全部操作。保存 vite.config.js,重启服务器,shader 就应该能被正确导入了。

如果你打印 testVertexShader 和 testFragmentShader,你会得到作为纯字符串的 shader 代码:

import testVertexShader from './shaders/test/vertex.glsl'
import testFragmentShader from './shaders/test/fragment.glsl'

console.log(testVertexShader)
console.log(testFragmentShader)

删除那些打印,并在 RawShaderMaterial 中使用这两个 shader:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader
})

属性

我们在其他 material 中介绍过的大多数常见属性,如 wireframe、side、transparent 或 flatShading,对于 RawShaderMaterial 仍然可用:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    wireframe: true
})

但像 map、alphaMap、opacity、color 等属性将不再起作用,因为我们需要自己在 shader 中编写这些功能。

注释掉或删除 wireframe 那一行。

GLSL

用于编写 shader 的语言叫做 GLSL,全称是 OpenGL Shading Language。它与 C 语言很接近。让我们学习它的基础语法。

日志输出

没有控制台,因此也无法打印值。这是因为代码会针对每个顶点和每个 fragment 执行。打印一个值是没有意义的。

缩进

缩进不是必需的。你可以按自己喜欢的方式缩进。

分号

分号是结束任何指令所必需的。哪怕漏掉一个分号也很可能导致编译错误,整个 material 都无法工作。

变量

它是一种强类型语言,这意味着我们必须指定变量的类型,并且不能给该变量赋其他类型的值。

要声明一个变量,我们必须先写类型,接着是名称(通常使用 camelCase),然后是 = 号,再是值,最后以 ; 结束:

float fooBar = 0.123;

有多种不同的类型。

Float

Float 是小数。它们可以是负数或正数。即使值是整数,我们也必须始终提供小数点的 .:

float foo = - 0.123;
float bar = 1.0;

我们可以进行数学运算,如 +、-、* 和 /:

float a = 1.0;
float b = 2.0;
float c = a / b;

整数

整数的工作方式与 float 一样,但没有小数的 .:

int foo = 123;
int bar = - 1;

我们也可以进行数学运算:

int a = 1;
int b = 2;
int c = a * b;

但我们不能在这些运算中混用 float 和 int:

float a = 1.0;
int b = 2;
float c = a * b;

但我们可以即时转换类型:

float a = 1.0;
int b = 2;
float c = a * float(b);

布尔值

布尔值就是布尔值:

bool foo = true;
bool bar = false;

向量 2

这里开始变得有趣了。如果我们想存储像具有 x 和 y 属性的 2 个坐标这样的值,我们可以使用 vec2:

vec2 foo = vec2(1.0, 2.0);

空的 vec2 会导致错误:

vec2 foo = vec2();

我们可以在创建 vec2 之后修改这些属性:

vec2 foo = vec2(0.0  );
foo.x = 1.0;
foo.y = 2.0;

像把 vec2 乘以 float 这样的运算会同时作用于 x 和 y 两个属性:

vec2 foo = vec2(1.0, 2.0);
foo *= 2.0;

向量 3

vec3 就像 vec2 一样,但多了一个名为 z 的属性。当需要 3D 坐标时它非常方便:

vec3 foo = vec3(0.0);
vec3 bar = vec3(1.0, 2.0, 3.0);
bar.z = 4.0;

我们可以使用 x、y 和 z,同时也可以使用 r、g 和 b。这只是语法糖,结果完全相同。当我们用 vec3 存储颜色时,它非常有效:

vec3 purpleColor = vec3(0.0);
purpleColor.r = 0.5;
purpleColor.b = 1.0;

一个 vec3 也可以部分地由 vec2 创建:

vec2 foo = vec2(1.0, 2.0);
vec3 bar = vec3(foo, 3.0);

我们也可以取 vec3 的一部分来生成一个 vec2:

vec3 foo = vec3(1.0, 2.0, 3.0);
vec2 bar = foo.xy;

这里,bar 将是一个值为 1.0 和 2.0 的 vec2。

这叫做 swizzle,我们还可以用不同的顺序使用这些属性:

vec3 foo = vec3(1.0, 2.0, 3.0);
vec2 bar = foo.yx;

向量 4

最后,vec4 的工作方式与前两者类似,但多了第四个值,名为 w 或 a——w 是因为字母表中 z 之后没有字母了,而 a 代表“alpha”:

vec4 foo = vec4(1.0, 2.0, 3.0, 4.0);
vec4 bar = vec4(foo.zw, vec2(5.0, 6.0));

还有其他类型的变量,如 mat2、mat3、mat4 或 sampler2D,但我们稍后会看到它们。

函数

就像大多数编程语言一样,我们可以创建并使用函数。

函数必须以将要返回的值的类型开头:

float loremIpsum()
{
    float a = 1.0;
    float b = 2.0;

    return a + b;
}

如果函数不应该返回任何内容,我们把类型设为 void:

void justDoingStuff()
{
    float a = 1.0;
    float b = 2.0;
}

我们可以指定参数,但也必须提供它们的类型:

float add(float a, float b)
{
    return a + b;
}

你可以想象,这个函数毫无用处。

内置函数

GLSL 有许多内置的经典函数,如 sin、cos、max、min、pow、exp、mod、clamp,也有非常实用的函数,如 cross、dot、mix、step、smoothstep、length、distance、reflect、refract、normalize。

不幸的是,没有对初学者友好的文档,大多数时候我们在网上做简单的搜索,通常最终会落到这三个网站:

Shaderific 文档

https://shaderific.com/glsl.html

Shaderific 是一个让你可以玩弄 GLSL 的 iOS 应用。这个应用本身没什么好在意的,但文档还算不错。

Kronos Group OpenGL 参考页面

https://www.khronos.org/registry/OpenGL-Refpages/gl4/html/indexflat.php

这份文档讲的是 OpenGL,但你看到的大多数标准函数都与 WebGL 兼容。别忘了 WebGL 只是一个访问 OpenGL 的 JavaScript API。

Book of shaders 文档

https://thebookofshaders.com/

The book of shaders 主要关注 fragment shader,与 Three.js 无关,但它是学习的好资源,并且有它自己的 glossary。

理解 vertex shader

现在我们已经了解了 GLSL 语法,让我们尝试理解我们的 shader 里有什么。

请记住,vertex shader 的目的是把 geometry 的每个顶点定位到 render 的 2D 空间上。换句话说,vertex shader 会把 3D 顶点坐标转换为我们 2D canvas 的坐标。

Main 函数

void main()
{
}

这个 main 函数会被自动调用。如你所见,它不返回任何内容(void)。

gl_Position

gl_Position 变量已经存在。我们需要给它赋值。这个变量将包含顶点在屏幕上的位置。main 函数中指令的目标就是正确地设置这个变量。

这条指令结束时,我们得到一个 vec4。这意味着我们可以直接在 gl_Position 变量上操作它的 x、y、z 和 w 属性:

void main()
{
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
    gl_Position.x += 0.5;
    gl_Position.y += 0.5;
}

平面应该会移动到右上角。但要小心;我们并没有像在 Three.js 中操作 position 那样真正在 3D 空间中移动平面。我们只是在 2D 空间中移动了投影后的平面。

把它想象成你在纸上画的一幅画。在这幅画中,你遵守了带有消失点的透视。然后,你把整幅画移到桌子的右上角。画中的透视并没有改变。

你可能想知道,如果 gl_Position 的最终目标是在 2D 空间中定位顶点,为什么我们需要 4 个值。这实际上是因为这些坐标并不精确地处于 2D 空间中;它们处于我们称之为 clip space 的空间中,而这需要 4 个维度。

clip space 是一个在所有 3 个方向(x、y 和 z)上从 -1 到 +1 范围的空间。就像把所有东西都定位在一个 3D 盒子里。任何超出这个范围的东西都会被“裁剪”并消失。第四个值(w)负责透视。

幸运的是,这一切都是自动的,作为初学者,我们不需要掌握所有内容。只是为了了解而已。

但我们究竟向这个 gl_Position 发送了什么呢?

Position attribute

首先,我们用以下方式获取顶点 position:

attribute vec3 position;

请记住,同一份代码会应用于 geometry 的每个顶点。Attribute 是唯一会在顶点之间变化的变量。同一个 vertex shader 会应用于每个顶点,而 position attribute 将包含那个特定顶点的 x、y 和 z 坐标。

然后,我们把这个 vec3 转换成 vec4:

gl_Position = /* ... */ vec4(position, 1.0);

这是因为接下来的矩阵以及 gl_Position 需要用到 vec4,正如我们之前看到的。

矩阵 uniform

每个矩阵都会变换 position,直到我们得到最终的 clip space 坐标。

我们的代码中有 3 个矩阵,由于它们的值对于 geometry 的所有顶点都相同,我们通过 uniform 来获取它们。

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

每个矩阵都会完成一部分变换:

如果你想进一步了解这些矩阵和坐标,这里有一篇好文章:https://learnopengl.com/Getting-started/Coordinate-Systems。

要应用一个矩阵,我们对它进行相乘。如果我们想把 mat4 应用到一个变量上,这个变量必须是 vec4。我们也可以把矩阵与其他矩阵相乘:

gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);

实际上有一个更短的版本,把 viewMatrix 和 modelMatrix 合并为一个 modelViewMatrix:

uniform mat4 projectionMatrix;
uniform mat4 modelViewMatrix;

attribute vec3 position;

void main()
{
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

这样更短,但我们对每一步的控制更少。

实际上我们会让代码变得更长,以便更好地理解并更精细地控制位置:

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

attribute vec3 position;

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    
    gl_Position = projectedPosition;
}

这些改动的结果完全相同,但我们现在只需调整 modelPosition 的值就能移动整个模型:

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.y += 1.0;

    // ...
}

整个平面应该会显得更高。

或者我们可以做一些更酷的事情,比如让我们的平面变成波浪:

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;

    // ...
}

我们通过 sin(...) 函数使用 x 坐标改变了 z。祝你能用 Three.js 内置 material 得到这个结果。

理解 fragment shader

fragment shader 代码会应用到 geometry 的每个可见 fragment。这就是 fragment shader 在 vertex shader 之后执行的原因。

代码比 vertex shader 更容易管理。

Main 函数

我们再次面对 main 函数:

void main()
{
}

精度

我们在代码顶部还有一条指令:

precision mediump float;

这条指令让我们决定 float 的精度可以有多高。有不同的可选值:

highp 可能带来性能损耗,在某些设备上甚至可能无法工作。lowp 可能因为精度不足而产生 bug。我们通常使用 mediump。我们也可以为 vertex shader 设置精度,但并不是必需的。

当我们使用 ShaderMaterial 而不是 RawShaderMaterial 时,这部分会被自动处理。

gl_FragColor

gl_FragColor 就像 gl_Position,但用于颜色。它已经声明好了,我们需要在 main 函数中给它赋值。

它是一个 vec4,前三个值分别是红、绿、蓝通道(r、g、b),第四个值是 alpha(a):

gl_FragColor = vec4(0.5, 0.0, 1.0, 1.0);

这段代码会让整个 geometry 呈现紫色。

gl_FragColor 的每个属性都从 0.0 到 1.0。我们可以超出这些值而不会报错,但这对我们没帮助。

如果我们想设置低于 1.0 的 alpha,我们还需要在 RawShaderMaterial 中把 transparent 属性设为 true:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    transparent: true
})

Attributes

Attribute 是在每个顶点之间变化的值。我们已经有一个名为 position 的 attribute,它包含每个顶点坐标的 vec3。

我们可以像在 Geometries 课中那样,直接向 BufferGeometry 添加我们自己的 attribute。

在本课中,我们会为每个顶点添加一个随机值,并根据该值把顶点沿 z 轴移动。

让我们回到 JavaScript,在创建 geometry 之后,创建一个大小合适的 Float32Array。要知道 geometry 中有多少顶点,我们可以使用已经存在的 position attribute:

const count = geometry.attributes.position.count
const randoms = new Float32Array(count)

然后我们用随机值填充这个数组:

for(let i = 0; i < count; i++)
{
    randoms[i] = Math.random()
}

最后,我们把这个数组用在一个 BufferAttribute 中,并把它添加到我们的 geometry attributes 里:

geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1))

setAttribute(...) 的第一个参数是 attribute 的名称。这就是我们在 shader 中使用的名称。我们可以选择任何名称,但用 a 作为前缀来表示“attribute”是个好习惯。

BufferAttribute 的第一个参数是数据数组,第二个参数是一个 attribute 由多少个值组成。如果我们要发送 position,我们会用 3,因为 position 由 3 个值(x、y 和 z)组成。但这里每个顶点只有 1 个随机值,所以我们用 1。

现在我们可以在 vertex shader 中获取这个 attribute,并用它来移动顶点:

// ...
attribute float aRandom;

void main()
{
    // ...
    modelPosition.z += aRandom * 0.1;

    // ...
}

现在你会得到一个由随机尖刺组成的平面。

Varyings

我们现在还想用 aRandom attribute 来给 fragment 着色。

遗憾的是,我们不能在 fragment shader 中直接使用 attribute。

幸运的是,有一种从 vertex shader 向 fragment shader 发送数据的方法,叫做 varyings。

我们必须在 vertex shader 和 fragment shader 中都进行操作。

在 vertex shader 中,我们需要在 main 函数之前创建这个 varying。我们把我们的 varying 命名为 vRandom:

// ...

varying float vRandom;

void main()
{
    // ...

你可以随意命名你的 varying,但我建议用 v 作为前缀,以便轻松区分它们。

然后,我们在 main 函数中更新 varying 的值:

varying float vRandom;

void main()
{
    // ...

    vRandom = aRandom;
}

最后,我们在 fragment shader 中用相同的声明获取 varying 的值,并在 main 函数中按我们的需要来使用它:

precision mediump float;

varying float vRandom;

void main()
{
    gl_FragColor = vec4(0.5, vRandom, 1.0, 1.0);
}

你现在会得到一个带有彩色尖刺的醒目形状。

varying 的一个有趣之处在于,顶点之间的值会被插值。如果 GPU 正在两个顶点之间绘制一个 fragment——一个顶点的 varying 为 1.0,另一个为 0.0——那么这个 fragment 的值将是 0.5。

让我们删除或注释掉抬升部分和 varying,这样我们就回到紫色的平面。

我们稍后会使用 attribute。

Uniforms

Uniform 是一种从 JavaScript 向 shader 发送数据的方式。

如果我们想使用同一个 shader 但用不同的参数,这会很有价值,同时也让我们能够拥有在体验过程中可以改变的参数。

我们可以在 vertex shader 和 fragment shader 中都使用 uniform,数据对每个顶点和每个 fragment 都相同。我们的代码中已经有 projectionMatrix、viewMatrix 和 modelMatrix 这些 uniform,但它们不是我们创建的。是 Three.js 创建的。

让我们创建自己的 uniform。

要往 material 中添加 uniform,使用 uniforms 属性。我们将让平面变成波浪,并且想要控制波浪的频率:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    uniforms:
    {
        frequency: { value: 10 }
    }
})

这里,我们选择的 uniform 名称是 frequency。虽然不是强制的,但用字母 u 作为前缀来区分“uniform”和其他数据被认为是一种好习惯。

把 uniform 的名称改为 uFrequency:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    uniforms:
    {
        uFrequency: { value: 10 }
    }
})

如果你查看其他教程或示例,可能会看到这样声明 uniform:uFrequency: { value: 10, type: 'float' }。曾经有一段时间我们必须指定类型,但现在已弃用了。

现在我们可以在 shader 代码中获取这个值,并在 main 函数中使用它:

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform float uFrequency;

attribute vec3 position;

void main()
{
    // ...
    modelPosition.z += sin(modelPosition.x * uFrequency) * 0.1;

    // ...
}

结果相同,但我们现在可以从 JavaScript 控制频率。

让我们把频率改为 vec2,以便在水平和垂直方向上控制波浪。我们只需使用一个 Three.js 的 Vector2:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    uniforms:
    {
        uFrequency: { value: new THREE.Vector2(10, 5) }
    }
})

在我们的 shader 中,我们把 float 改成 vec2,并通过使用 y 轴也在 z 轴上应用位移:

// ...
uniform vec2 uFrequency;

// ...

void main()
{
    // ...
    modelPosition.z += sin(modelPosition.x * uFrequency.x) * 0.1;
    modelPosition.z += sin(modelPosition.y * uFrequency.y) * 0.1;

    // ...
}

这一部分慢慢来。很容易出错。

因为这些值现在在 JavaScript 中控制,我们可以把它们添加到我们的 lil-gui 中:

gui.add(material.uniforms.uFrequency.value, 'x').min(0).max(20).step(0.01).name('frequencyX')
gui.add(material.uniforms.uFrequency.value, 'y').min(0).max(20).step(0.01).name('frequencyY')
lesson demo video preview

让我们添加一个新的 uniform,让平面像风中的旗帜一样动起来。我们通过一个 uniform 向 shader 发送时间值,并在 sin(...) 函数中使用它。首先,更新 material 以添加 uTime uniform:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    uniforms:
    {
        uFrequency: { value: new THREE.Vector2(10, 5) },
        uTime: { value: 0 }
    }
})

然后,在 tick 函数中更新这个 uTime uniform。为此,使用 Clock 的 getElapsedTime 函数来了解经过了多少时间:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update material
    material.uniforms.uTime.value = elapsedTime

    // ...
}

最后,我们在 vertex shader 中获取这个 uniform 的值,并在两个 sin(...) 函数中使用它:

// ...
uniform float uTime;

// ...

void main()
{
    // ...
    modelPosition.z += sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
    modelPosition.z += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;

    // ...
}
lesson demo video preview

你应该能看到旗帜像在风中飘动一样摆动。

让我们通过在 uTime 前使用 - 而不是 + 来反转方向:

modelPosition.z += sin(modelPosition.x * uFrequency.x - uTime) * 0.1;
    modelPosition.z += sin(modelPosition.y * uFrequency.y - uTime) * 0.1;
lesson demo video preview

小心 uTime:如果我们使用像 Date.now() 这样的原生 JavaScript 方案,它不会生效。这是因为 Date.now() 返回自 1970 年 1 月 1 日以来经过的毫秒数,而这个值对 shader 来说太大了。简而言之,记住我们不能发送太大或太小的 uniform 值。

别忘了这仍然是一个平面,我们可以像以前一样变换 Mesh。让我们把平面做成旗帜的形状。

我们可以在 shader 中通过乘以 modelPosition.y 的值来做到,但别忘了你仍然可以直接在 mesh 上修改 position、scale 和 rotation:

const mesh = new THREE.Mesh(geometry, material)
mesh.scale.y = 2 / 3
scene.add(mesh)
lesson demo video preview

Uniform 在 fragment shader 中同样可用。让我们添加一个新的 uniform 来控制颜色。对于颜色值,我们可以使用一个 Three.js 的 Color:

const material = new THREE.RawShaderMaterial({
    vertexShader: testVertexShader,
    fragmentShader: testFragmentShader,
    uniforms:
    {
        uFrequency: { value: new THREE.Vector2(10, 5) },
        uTime: { value: 0 },
        uColor: { value: new THREE.Color('orange') }
    }
})

然后,在我们的 fragment shader 中,我们获取这个值,并在 gl_FragColor 的 vec4 中使用它:

precision mediump float;

uniform vec3 uColor;

void main()
{
    gl_FragColor = vec4(uColor, 1.0);
}

现在旗帜应该是橙色的了。

Textures

Texture 稍微难一些,但我们几乎已经拥有了所需的一切。

首先,我们必须像之前的课程中那样加载一个 texture。我们在 /static/textures/ 文件夹中已经有一个旗帜 texture,并且可以使用 JavaScript starter 中已经可用的 textureLoader:

const flagTexture = textureLoader.load('/textures/flag-french.jpg')

然后我们可以把这个 texture 作为 uniform 发送。我们可以叫它 uTexture:

const material = new THREE.RawShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uTexture: { value: flagTexture }
    }
})

虽然马上把它发送到 fragment shader 很诱人,但我们有个问题。要从 texture 中获取 fragment 颜色并在 fragment shader 中应用它们,我们必须使用 texture2D(...) 函数。texture2D(...) 的第一个参数是 texture(很简单,就是我们的 uTexture),但第二个参数是用于在该 texture 上拾取颜色的坐标,而我们还没有这些坐标。

这个信息应该听起来很熟悉。我们在寻找能够帮助我们把 texture 投影到 geometry 上的坐标。我们说的是 UV 坐标。

PlaneGeometry 会自动生成这些坐标,如果我们打印 geometry.attributes.uv 就能看到。

console.log(geometry.attributes.uv)

因为它是一个 attribute,我们可以在 vertex shader 中获取它:

attribute vec2 uv;

不过,我们在 fragment shader 中需要这些坐标。要从 vertex shader 向 fragment shader 发送数据,我们需要创建一个 varying。我们将把这个 varying 叫做 vUv,并在 main 函数中更新它的值:

// ...
attribute vec2 uv;

varying vec2 vUv;

void main()
{
    // ...

    vUv = uv;
}

现在我们可以在 fragment shader 中获取 varying vUv,获取 uniform uTexture,最终用 texture2D(...) 得到 fragment 颜色:

precision mediump float;

uniform vec3 uColor;
uniform sampler2D uTexture;

varying vec2 vUv;

void main()
{
    vec4 textureColor = texture2D(uTexture, vUv);
    gl_FragColor = textureColor;
}

texture2D(...) 的输出是一个 vec4,因为它包含 r、g、b 和 a——即使我们的 texture 没有 alpha 变化。

你应该会得到一面好看的旗帜。

再说一次,慢慢来。这里很容易犯错。

颜色变化

我们旗帜的颜色变化不大。如果能像有阴影一样有明暗变化就好了。

我们将要使用的技术并不符合物理准确性,但应该能达到效果。

首先,在 vertex shader 中,我们将把风的抬升量存储在一个变量中:

void main()
{
    // ...

    float elevation = sin(modelPosition.x * uFrequency.x - uTime) * 0.1;
    elevation += sin(modelPosition.y * uFrequency.y - uTime) * 0.1;
    
    modelPosition.z += elevation;

    // ...
}

然后,我们通过一个 varying 把抬升量发送到 fragment:

// ...
varying float vElevation;

void main()
{
    // ...

    vElevation = elevation;
}

最后,我们在 fragment shader 中获取 vElevation varying,并用它来改变 textureColor 的 r、g 和 b 属性:

// ...
varying float vElevation;

void main()
{
    vec4 textureColor = texture2D(uTexture, vUv);
    textureColor.rgb *= vElevation * 2.0 + 0.5;
    gl_FragColor = textureColor;
}
lesson demo video preview

你应该能在旗帜上看到明暗变化,就像有光和阴影一样。这是一种廉价的技术,但它有效。

ShaderMaterial

到目前为止,我们一直使用的是 RawShaderMaterial。ShaderMaterial 的工作方式完全相同,但会在 shader 代码前预置一些内置的 uniform 和 attribute。精度也会自动设置。

要使用它,只需把你的 RawShaderMaterial 替换为 ShaderMaterial:

const material = new THREE.ShaderMaterial({
    // ...

然后删除两个 shader 中以下的 uniform、attribute 和 precision:

uniform vec2 uFrequency;
uniform float uTime;

attribute float aRandom;

varying float vElevation;
varying vec2 vUv;

void main()
{
    // ...
}

shader 应该会像之前一样正常工作,因为 ShaderMaterial 会自动添加这些内容。

调试

调试 shader 很困难。我们不能像在 JavaScript 中那样打印数据,因为执行 shader 代码的是 GPU,而且它会针对每个顶点和每个 fragment 执行。

对我们有利的是,Three.js 在传递编译错误方面做得很好。

找到错误

如果我们忘记一个分号,Three.js 会打印完整的 shader,并告诉我们错误发生的行号,附上简短的描述,如 ERROR: 0:71: 'vec4' : syntax error。

这条消息意味着错误发生在第 71 行,但问题可能来自它前面那一行。慢慢来,阅读错误信息,你就会找到问题所在。

阅读 shader

打印整个 shader 代码也是查看 Three.js 在使用 ShaderMaterial 时向我们的 shader 前部添加了什么内容的绝佳方式。

测试值

调试值的另一个解决方案是在 gl_FragColor 中使用它们。这并不精确,因为我们只能看到颜色变化,但有时这已经足够了。

如果这些值在 vertex shader 中,我们可以使用一个 varying 把它们传递到 fragment shader。

比如说我们想看看 uv 长什么样。我们可以用一个 varying 把它发送到 fragment——已经用 vUv 做过了——并在 gl_FragColor 中使用它:

gl_FragColor = vec4(vUv, 1.0, 1.0);

进一步探索

一旦我们理解了 shader 的基础知识,接下来就全靠练习了。你的第一批 shader 会花费无数小时,但你会学到经常使用的技巧。

在接下来的课程中,我们会练习这些技巧,甚至学习如何在 shader 中绘制形状,但如果你想进一步深入,这里有一些链接: